Skip to content

fix: make rate limiter checks independent of window size (#125) - #130

Merged
jonbaldie merged 7 commits into
mainfrom
fleet/worktree-queue-125
Sep 22, 2026
Merged

jonbaldie merged 7 commits into
mainfrom
fleet/worktree-queue-125

Conversation

@jonbaldie

Copy link
Copy Markdown
Owner

Fixes #125

Diagnosis

RateLimiter.isAllowed() (src/rate_limiter.ts) ran timestamps.filter(ts => ts > cutoff) on every call — an O(window) scan and allocation per request, paid before authentication (rate limiting wraps auth) and even for requests that end up denied. Filling one caller's window and issuing denied requests therefore scaled linearly with RATE_LIMIT_REQUESTS, collapsing quadratically under sustained load.

Confirmed with a live benchmark (docs/exploratory-testing/2026-09-21-queue/perf_repro.ts), matching the triage timings on the issue:

window before after
1,000 0.018 ms/req 0.007 ms/req
10,000 0.10–0.13 ms/req 0.002–0.006 ms/req
growth ratio 3.8–9.5× (linear) 0.4–1.4× (flat)

Fix

Timestamps are appended in non-decreasing order, so stale entries always form a prefix of each caller's array. Instead of filtering on every call:

  • Fast path (O(1)): if the newest timestamp is fresh, nothing is stale.
  • Otherwise binary search the first fresh timestamp — O(log n) — and make the allow/deny decision from freshCount = length - firstFresh, without copying the window.
  • Drop the stale prefix only once it dominates the array (firstFresh * 2 >= length), so the copy stays amortized O(1) per recorded request.
  • All-stale entries are still deleted (then re-added on the next allowed request); cleanupStaleEntries(), maxTrackedIPs eviction (last element = max timestamp still holds), the sliding-window boundary (ts > cutoff), client identity, and the 429 contract are unchanged.

Tests

  • High-window behavior: 10,000-entry fill denies at the limit; a fully stale 10,000-entry entry recovers with a single fresh timestamp; a stale prefix does not count toward the limit and is trimmed exactly when it is half the array (>=, not >).
  • Perf canary: 2,000 denied requests at a full 10,000-entry window must complete in < 80ms (pre-fix ~210ms; post-fix ~1-3ms timed section) — fails on the old implementation, passes on the new one.
  • All 34 existing rate-limiter tests pass unchanged (eviction ordering, cleanup sweeps, boundary conditions).

Note: persist.ts mutation coverage

The second commit is CI infrastructure repair, not part of the rate-limit fix: the atomic-snapshot rewrite in #122 left src/persist.ts at 78–79% on the Stryker mutation gate (80% per-file threshold), which has kept main's Mutation testing (Stryker) job red since Sep 20. Any PR touching tests runs the full mutation suite, so this PR cannot go green without restoring those kills. The new tests kill the survivors through the public FileStore interface only: full truncation of stale temp content, rethrowing when the temp path is unusable, temp cleanup on failed rename, and multi-byte reassembly across 4096-byte read boundaries.

jonbaldie and others added 7 commits September 21, 2026 11:02
isAllowed() filtered the caller's full timestamp window on every call,
including denied requests and pre-auth traffic, so per-request cost grew
linearly with RATE_LIMIT_REQUESTS and collapsed quadratically under load.

Timestamps are sorted ascending, so stale entries form a prefix: take an
O(1) fast path when the newest is fresh, otherwise binary search the
first fresh entry (O(log n)) and count the window without filtering or
copying. Drop the stale prefix only once it dominates the array so the
copy stays amortized O(1) per recorded request. All-stale entries are
still removed, and periodic cleanup and eviction semantics are unchanged.
The atomic-snapshot rewrite in #122 left persist.ts at 78-79% on
Stryker, failing the per-file threshold for any PR that touches tests.
Kill the surviving mutants through the public FileStore interface:
full truncation of stale temp content, rethrowing unusable-temp-path
errors, temp cleanup on failed rename, and multi-byte reassembly
across 4096-byte read boundaries.
#128)

* fix: return 400 for JSON nested beyond the parser's stack depth (#123)

Root cause: V8 applies a JSON.parse reviver recursively, one stack frame
per nesting level. The enqueue reviver (unsupported-number check) made
bodies nested ~3,100+ levels deep throw RangeError: Maximum call stack
size exceeded. enqueueErrorResponse only maps SyntaxError, so the
RangeError escaped as an uncaught 500. Plain JSON.parse and
JSON.stringify both handle 100,000+ levels, so the parse step was the
only point of failure.

parseJsonBody now converts a RangeError raised by JSON.parse into a
SyntaxError. The request gets the existing 400 "Invalid JSON" response
and the Queue is not changed. The catch covers only the parse call, so
RangeErrors from anywhere else still surface as 500s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor: map parser depth overflow via a local error type

Throwing `new SyntaxError` added a module dependency and pushed
handler.ts to the CouplingBetweenObjects limit (13) in the production
quality gate. A local JsonNestingTooDeepError, mapped to the same
400 "Invalid JSON" response, follows the existing UnsupportedNumberError
pattern and keeps coupling at 12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: restore persist.ts mutation coverage below the 80% gate

The atomic-snapshot rewrite in #122 left persist.ts at 78-79% on
Stryker, failing the per-file threshold for any PR that touches tests.
Kill the surviving mutants through the public FileStore interface:
full truncation of stale temp content, rethrowing unusable-temp-path
errors, temp cleanup on failed rename, and multi-byte reassembly
across 4096-byte read boundaries.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Decode request bytes with a fatal UTF-8 decoder so malformed JSON strings return the existing 400 Invalid JSON response instead of being replaced with U+FFFD. Add HTTP seam coverage for rejection, no enqueue, and valid UTF-8 round trips.
Parse request bodies natively, then scan the original JSON source to validate number literals. This preserves exact-number rejection while avoiding a reviver callback for every value in number-dense payloads.

Add public handler regressions for exact integers, nested metadata, JSON strings, and the explicit nesting limit.
@jonbaldie
jonbaldie merged commit 4685910 into main Sep 22, 2026
4 checks passed
@jonbaldie
jonbaldie deleted the fleet/worktree-queue-125 branch September 22, 2026 08:55
@jonbaldie jonbaldie mentioned this pull request Sep 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Performance: RateLimiter.isAllowed is O(window) per request and collapses quadratically under sustained load; cost is paid before auth

1 participant